Skip to content

feat(daemon): restore worktree isolation on session load/resume - #7262

Merged
wenshao merged 17 commits into
QwenLM:mainfrom
wenshao:feat/worktree-restore-isolation
Jul 20, 2026
Merged

feat(daemon): restore worktree isolation on session load/resume#7262
wenshao merged 17 commits into
QwenLM:mainfrom
wenshao:feat/worktree-restore-isolation

Conversation

@wenshao

@wenshao wenshao commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Fixes the restart-persistence gap identified in the E2E verification of #7221: after daemon restart, worktree sessions disappeared from the session list entirely because SessionService.sessionBelongsToCurrentProject compared getProjectHash(recordCwd) against the workspace project hash — and the worktree path has a different hash.

Two fixes:

  1. Session listing (SessionService.sessionBelongsToCurrentProject): Added a marker-based membership check — if the session's transcript cwd contains a .qwen/worktrees/ segment, the repo root is inferred from the path and its project hash is compared. This is durable (no sidecar dependency, survives worktree removal) and pure string ops (no file I/O), so it runs before the readRuntimeStatus file read.

  2. Session load/resume (POST /session/:id/load and /resume): After loading a session, reads the worktree sidecar and — if found — calls changeSessionCwd to relocate the session into the worktree and setSessionWorktree to populate the bridge entry. The load/resume response includes the worktree metadata so the Web Shell restores the purple ⑂ chip.

Also adds setSessionWorktree(sessionId, worktree) to the AcpSessionBridge interface for populating worktree metadata on existing entries.

Why it is needed

#7221 added worktree session support with sidecar persistence, but the sidecar enrichment in session-list.ts was dead code on the restart path — sessions were filtered out by SessionService before the enrichment loop ran. This PR closes that gap.

Reviewer Test Plan

How to verify

  1. npm run build && npm run dev:daemon
  2. Create a worktree session via the Web Shell (⑂ button in workspace pill)
  3. Send a message so the transcript persists
  4. Restart the daemon (Ctrl+C + npm run dev:daemon)
  5. The worktree session should appear in the sidebar with the ⑂ badge
  6. Click the session → the git chip should turn purple with the worktree branch name

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Risk & Scope

  • Main risk: the marker-based membership check in sessionBelongsToCurrentProject adds pure string operations (no file I/O) per non-matching session during listing. The sidecar read on the load/resume path is one small file read (<1KB) per worktree session load.
  • Graceful degradation: if the worktree directory was removed, changeSessionCwd fails silently and the session continues in the main workspace.
  • No breaking changes.

Linked Issues

Follow-up to #7221 (E2E verification comment: restart-persistence gap)

中文说明

修复 #7221 E2E 验证发现的重启持久化缺陷:daemon 重启后 worktree 会话从列表消失。

根因:SessionService.sessionBelongsToCurrentProjectgetProjectHash(recordCwd) 判断归属,worktree 路径的 hash 与主 workspace 不同,导致会话被过滤。

修复:

  1. 列表路径:加基于路径标记的归属检查——从 transcript 的 cwd 中提取 .qwen/worktrees/ 段推断 repo root,纯字符串操作无文件 I/O
  2. 加载路径:load/resume 后读 sidecar → changeSessionCwd 重定位 → setSessionWorktree 填充 bridge entry

wenshao added 3 commits July 19, 2026 19:18
Add support for creating sessions in isolated git worktrees from the
Web Shell, enabling multiple tasks to run in parallel within the same
workspace without polluting the main working directory.

Daemon:
- POST /session accepts optional worktree param, creates worktree via
  GitWorktreeService, relocates session via changeSessionCwd
- Worktree metadata persisted in SessionEntry, BridgeSessionSummary,
  and sidecar file (<sessionId>.worktree.json) for daemon restart
  recovery
- GET /workspaces/:workspace/git supports ?cwd= for worktree-scoped
  git status queries (path.resolve + containment check)

SDK:
- CreateSessionRequest/DaemonSession/DaemonSessionSummary gain
  worktree field; DaemonSessionClient exposes worktree getter
- WorkspaceDaemonClient.workspaceGit() accepts optional cwd param

Web Shell:
- Workspace branch pill dropdown offers 'New Worktree Task' (git repos
  only) with purple GitForkIcon and description
- Git chip turns purple with GitForkIcon for worktree sessions
- Session list shows inline ⑂ badge for worktree sessions
- Empty-state welcome badge explains worktree isolation
- Git status queries target worktree path, not workspace root
- session_cwd_changed event filtered from chat transcript

Design doc: docs/design/2026-07-19-webshell-worktree-sessions.md
After daemon restart, loading a worktree session now:
1. Reads the worktree sidecar file (<sessionId>.worktree.json)
2. Calls changeSessionCwd to relocate the session into the worktree
3. Populates the bridge entry via setSessionWorktree so
   GET /session/:id/status returns worktree metadata
4. Includes worktree info in the load/resume response

If the worktree directory was removed, the session continues in the
main workspace without isolation (graceful degradation).

Bridge: adds setSessionWorktree(sessionId, worktree) to AcpSessionBridge
interface and implementation.
@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Re-verification ✅ — the PR 7221 restart-persistence gap is fixed

This is the follow-up to the finding I reported on #7221 (worktree session disappeared from the session list after a daemon restart). I rebuilt on top of this branch (feat/worktree-restore-isolation, f7951df = #7221 + this one commit) and re-ran the same real-daemon + real-git harness. All 12/12 backend checks now pass, and the previously-broken restart case is resolved.

Before fix (PR 7221) vs after fix (PR 7262)

What the fix does (and why it resolves the finding)

  • sessionService.tssessionBelongsToCurrentProject now falls back to the sidecar's originalCwd (the main repo root) when the recorded cwd (the worktree path) hashes to a different project. This is exactly the filter that was dropping the session — so worktree sessions are no longer excluded from the list before enrichment runs.
  • restoreSessionHandler (POST /session/:id/load · /resume) now reads the sidecar → changeSessionCwd back into the worktree → setSessionWorktree on the bridge entry → returns worktree in the load response. So the purple chip + cwd isolation also come back on load, with graceful degradation if the worktree dir was deleted.

Re-verification results (real qwen serve daemon, no GitWorktreeService mocking)

Check PR 7221 PR 7262
Worktree session listed after daemon restart (with worktree metadata) ❌ dropped (total=0) total=1, branch=worktree-alpha
Sidebar ⑂ badge persists after restart (live web-shell) ❌ 0 fork icons ✅ 1 fork icon (⑂ Refactor auth)
POST /session/:id/load returns worktree metadata n/a worktree-alpha
cwd relocated back into the worktree on load n/a ✅ git-scoped to worktree-alpha
Create / isolation / ?cwd= scoping + containment / validation gates ✅ (no regression)
==== full harness: SUMMARY: 12 passed, 0 failed ====   (was 11 passed, 1 failed on #7221)
  PASS worktree session STILL LISTED after restart (1)
  PASS worktree branch preserved in list (worktree-alpha)
  PASS load response carries worktree metadata (worktree-alpha)
  PASS worktree dir still git-scoped to worktree branch (worktree-alpha)

Notes

  • Two minor items from the feat(web-shell): worktree-isolated sessions for parallel tasks #7221 review still stand (both non-blocking): the daemon-written sidecar leaves originalBranch/originalHeadCommit as "", and creating a worktree makes the parent repo show .qwen/ as untracked unless it is git-ignored.
  • This branch currently shows as conflicting with main on GitHub — worth a rebase before merge.

Nice fix — the restart path now behaves as the #7221 Test-Plan described. 👍

🇨🇳 中文版

复验 ✅ —— #7221 的重启持久化缺陷已修复

这是我在 #7221 上所报缺陷(daemon 重启后 worktree 会话从列表整行消失)的跟进复验。我在本分支(feat/worktree-restore-isolationf7951df = #7221 + 这一个提交)上重新构建,并用同一套真实 daemon + 真实 git 脚本复跑。后端 12/12 全部通过,此前失败的重启场景已解决。

修复原理(为何能解决该缺陷):

  • sessionService.tssessionBelongsToCurrentProject:当记录的 cwd(worktree 路径)哈希到不同 project 时,改为回退检查 sidecar 的 originalCwd(主仓库根)。这正是此前把会话过滤掉的那道判断——因此 worktree 会话不再在 enrichment 之前被排除。
  • restoreSessionHandlerPOST /session/:id/load·/resume:现在会读取 sidecar → changeSessionCwd 回到 worktree → setSessionWorktree 写回 bridge entry → 在 load 响应中返回 worktree。因此加载时紫色 chip 与 cwd 隔离也会恢复;若 worktree 目录已被删除则优雅降级。

复验结果(真实 qwen serve daemon,未 mock GitWorktreeService):

完整脚本:12 passed, 0 failed#7221 上为 11 passed, 1 failed)。

备注: #7221 复审中的两个次要项仍在(均非阻塞):daemon 写入的 sidecar 中 originalBranch/originalHeadCommit 为空串;创建 worktree 会让父仓库把 .qwen/ 报为未跟踪(除非 gitignore)。此外本分支目前与 main 冲突,合并前建议 rebase。

修复很到位——重启路径现在符合 #7221 测试计划的预期。👍


Re-verified locally on Linux against a real qwen serve daemon + real git repo (no GitWorktreeService mocking) and the production web-shell in a headless browser.

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review:worktree isolation 重启恢复

整体方向正确、实现基本可靠——补上了 #7221 遗留的“重启后 worktree 会话从列表消失、session-list.ts 的 sidecar enrichment 成为死代码”缺口。类型安全、workspace 归属、sidecar 写读一致性、幂等性均已核对通过。建议合并前先看下面两点。

建议合并前处理

1. 归档的 worktree 会话重启后仍会消失(修复不完整,正好在本 PR 范围内)

packages/core/src/services/sessionService.tssessionBelongsToCurrentProject 新增的第三个检查只读 active 状态 sidecar:

readWorktreeSession(this.getWorktreeSessionPath(sessionId))  // 仅 active

archiveSessions 会把 sidecar 从 active 移动到 archived 目录。于是已归档的 worktree 会话重启后三个检查全部失败被过滤,导致 packages/cli/src/serve/server/session-list.ts 里已经用 getWorktreeSessionPathForArchiveState(..., 'archived') 正确处理 archived 的 enrichment 分支依旧是死代码——正是本 PR 想消灭的同类问题。
→ membership 检查建议同时尝试 active + archived 两个 sidecar 路径(或让方法感知 state)。

2. 恢复路径绕过了防篡改的路径包含校验

共享 helper restoreWorktreeContext(TUI / headless / ACP 三个 resume 入口共用)会校验 worktreePath 必须位于 <originalCwd>/.qwen/worktrees/ 之下,用于防止被篡改的 sidecar 把文件操作重定向到 /etc、~/ 等。
本 PR 在 packages/cli/src/serve/routes/session.ts 的 load/resume 直接 readWorktreeSession + changeSessionCwd跳过了该校验,成为第 4 个偏离共享约定的 resume 入口。唯一兜底是 agent 侧 sessionCd 的 folder-trust 检查,而它在 folder trust 关闭时是 no-op。
→ 建议复用 restoreWorktreeContext(顺带白拿 stale-sidecar 清理与目录存活检查),或至少补上同样的 expectedParent 包含校验。

建议

3. 缺测试:membership 第三检查、load/resume 恢复、setSessionWorktree 三个新行为均无覆盖。该路径已回归过一次,建议补一个重启恢复的回归测试。

次要 / Nit

  • setSessionWorktreepackages/acp-bridge/src/bridge.ts)只改内存不广播,其他订阅者要等重新拉取才看到 ⑂ chip;对照 updateSessionMetadata 是会广播的。
  • 恢复失败的 catch {} 静默吞错——无 daemonLog.warn(POST 路径有),也不清理 stale sidecar(restoreWorktreeContext 会)。反复恢复失败时缺排查线索。
  • load/resume handler 里 new SessionService(workspaceCwd) 构造了两次(metadata 一次、取 sidecar 路径一次),可复用一个实例。

已核对通过

类型(BridgeRestoredSession extends BridgeSessionworktree?,与 wt{slug,path,branch} 一致,Object.assign 合法)、写读一致(写侧 originalCwd = workspaceCwd 且各 project 独立 chats 目录,无跨项目误判)、主 workspace 会话在 check1 短路无额外 I/O、!session.worktree 保证不重复 cd、load/resume 全程用解析出的 runtime 未回退 primary、core barrel 已 export * 导出 readWorktreeSession

…-isolation

# Conflicts:
#	packages/cli/src/serve/routes/session.ts
@wenshao
wenshao force-pushed the feat/worktree-restore-isolation branch from bf579b0 to c4b8b22 Compare July 20, 2026 02:23
@github-actions

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Fixes for doudouOUC review (c4b8b22)

1. Archived sidecar path ✅ — sessionBelongsToCurrentProject now checks both active and archived sidecar paths via getWorktreeSessionPathForArchiveState. Archived worktree sessions survive daemon restart.

2. Path containment validation ✅ — Added expectedParent check: sidecar.worktreePath must start with <originalCwd>/.qwen/worktrees/. Prevents a tampered sidecar from redirecting file operations. Matches the restoreWorktreeContext containment convention.

Nits fixed:

  • Silent catch {}daemonLog.warn with sessionId, worktreePath, and error message
  • Reused SessionService instance (single new SessionService(workspaceCwd) instead of two)

Acknowledged (follow-up):

  • setSessionWorktree broadcast: valid, will add event emission in follow-up
  • Test coverage for membership check / load-resume restore / setSessionWorktree: will add regression tests

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head 5efdb97, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

No response changes against the PR base across 4 scenario(s).

Qwen Code · serve A/B

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Downgraded from Request changes to Comment: self-PR; CI failing: review-pr, Remind on force-push. Reviewed. Suggestions are inline. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

— qwen3.8-max-preview via Qwen Code /review

Comment thread packages/cli/src/serve/routes/session.ts
Comment thread packages/core/src/services/sessionService.ts Outdated
Comment thread packages/cli/src/serve/routes/session.ts
Comment thread packages/cli/src/serve/routes/session.ts
@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Re-review @ c4b8b22

Two of the earlier points are cleanly resolved:

  • Archived sidecar — the ['active', 'archived'] loop over getWorktreeSessionPathForArchiveState is right, and it correctly picks up the sidecar that archiveSessions moves.
  • Silent catchdaemonLog.warn with sessionId + worktreePath is a real improvement; daemonLog is in scope (destructured at session.ts:298).

The containment fix, however, is not equivalent to the guard it's replicating, and I don't think it holds.


1. The inlined containment check is bypassable by .. — blocking

const expectedParent = sidecar.originalCwd + '/.qwen/worktrees/';
if (sidecar.worktreePath.startsWith(expectedParent)) {

The canonical guard (worktreeSessionService.ts:423-428) resolves before comparing:

const expectedParent = path.join(session.originalCwd, '.qwen', 'worktrees');
const resolvedWorktree = path.resolve(session.worktreePath);   // <-- this
if (!resolvedWorktree.startsWith(expectedParent + path.sep) &&
    resolvedWorktree !== expectedParent)

Dropping the path.resolve() makes it a pure string-prefix test, which a traversal walks straight through:

originalCwd:  /home/u/repo
worktreePath: /home/u/repo/.qwen/worktrees/../../../../etc
  • startsWith('/home/u/repo/.qwen/worktrees/')true, guard passes
  • changeSessionCwdsessionCd (acpAgent.ts:6750-6790): path.isAbsolute ✅, fs.stat ✅, then fs.realpath/etc
  • folder-trust check is gated behind isFolderTrustEnabled(...), off by default

Session cwd ends up at /etc. That's the same outcome as before the fix, so the hole this hunk is meant to close is still open.

2. Same check is broken on Windows (fails closed)

worktreePath is built with path.join (gitWorktreeService.ts:415,1525), so on Windows it contains \ separators — ...\.qwen\worktrees\slug. The hardcoded '/.qwen/worktrees/' prefix can never match it, so startsWith is always false and worktree restore silently never fires on Windows. This is exactly why the canonical guard uses path.join + path.sep. Note the PR's own test matrix still marks Windows ⚠️.

3. Both of the above go away by calling the shared helper

path is already imported (session.ts:8) and restoreWorktreeContext is already exported from the core package (packages/core/src/index.ts:279 re-exports the whole module), so this is a small swap:

const { session: sidecar } = await restoreWorktreeContext(
  svc.getWorktreeSessionPath(sessionId),
  (e) => daemonLog?.warn('worktree sidecar restore', { sessionId, error: String(e) }),
);
if (sidecar) { /* ... */ }

Beyond fixing 1 and 2, this also gets the directory-liveness stat and — the part still missing here — clearWorktreeSession() on a stale sidecar. As written, a sidecar pointing at a deleted worktree re-fails changeSessionCwd on every load and resume forever; it's logged now, but never cleaned up.

The general point: this is the fourth copy of an invariant that already has one canonical implementation and a regression test (worktreeSessionService.test.ts:185). The drift in 1 and 2 is the predictable cost of hand-rolling it a fourth time.


4. Still open from the previous round: originalCwd isn't the workspace cwd

Unchanged in c4b8b22. originalCwd is the repo top-levelenter-worktree.ts:117,203 (projectRoot = (await probe.getRepoTopLevel()) ?? cwd) and worktreeStartup.ts:427 (context.repoRoot) — and the field's own docstring warns:

When the CLI is launched from a monorepo subdirectory, process.cwd() and getRepoTopLevel() differ — this field stores the latter. Consumers expecting process.cwd() semantics should NOT use this field.

this.projectHash hashes whatever cwd SessionService was constructed with. So with qwen serve --workspace /repo/packages/cli, getProjectHash(sidecar.originalCwd) is hash(/repo) and never equals hash(/repo/packages/cli) — the session stays filtered and the fix silently doesn't apply. Either resolve the workspace to its repo top-level before comparing, or persist the workspace cwd in the sidecar as its own field.

5. Still no tests

c4b8b22 still touches exactly 4 non-test files. The update actually added untested surface — the containment branch is new logic, and both defects above (the missing resolve, the separator assumption) are precisely what a unit test pins down:

  • sessionService.test.ts — positive path (sidecar originalCwd hashes to project → true), the archived variant, and a non-matching negative. The existing "different project" test passes either way, since the unmocked read already rejects to null.
  • Route-level — success path (changeSessionCwd + setSessionWorktree + response carries worktree) and the failure path.
  • A worktreePath: '<root>/.qwen/worktrees/../../etc' case, which fails against the current check and passes against restoreWorktreeContext.

AGENTS.md asks for collocated unit tests on changed files plus a .qwen/e2e-tests/ plan for behavioral changes; neither is present.

Replace the hand-rolled containment check (startsWith, bypassable by
'..' traversal, broken on Windows) with the shared restoreWorktreeContext
helper which uses path.resolve + path.sep (cross-platform), validates
directory liveness, and clears stale sidecars automatically.
@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Fix for re-review (f184757)

Critical: containment check bypassable by .. + broken on Windows

Replaced the hand-rolled startsWith check with the shared restoreWorktreeContext helper (f184757). This gets:

  • path.resolve() + path.sep containment (cross-platform, ..-safe)
  • Directory liveness check (fs.stat)
  • Automatic stale sidecar cleanup (clearWorktreeSession)
  • The same canonical implementation used by TUI/headless/ACP resume paths

#4 originalCwd vs workspace cwd: Acknowledged. For monorepo subdirectory workspaces (qwen serve --workspace /repo/packages/cli), originalCwd is the repo top-level and the project hash comparison fails. This is a pre-existing data model issue with the sidecar schema. Will address by persisting the workspace cwd as a separate sidecar field in a follow-up.

#5 Tests: Acknowledged. Will add regression tests for membership check, load/resume restore, and the .. traversal case.

@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /takeover

@qwen-code-dev-bot qwen-code-dev-bot added the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Jul 20, 2026
@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Re-review @ f184757

The containment issues are resolved properly — routing through restoreWorktreeContext fixes the .. bypass and the Windows separator problem in one move, and picks up liveness checking and stale-sidecar cleanup as a bonus. The ['active','archived'] loop and the daemonLog.warn calls both look right. Import is a value import, daemonLog is in scope.

Two things surfaced by this revision that I don't think were visible before.


1. Sidecar cleanup now destroys the membership signal the listing half depends on

These two halves of the PR pull against each other.

The listing half makes project membership depend on the sidecar existing:

const sidecar = await readWorktreeSession(
  this.getWorktreeSessionPathForArchiveState(sessionId, state),
).catch(() => null);
if (sidecar != null && getProjectHash(sidecar.originalCwd) === this.projectHash) return true;

But restoreWorktreeContext — which the load path now calls — deletes that sidecar on every non-happy path: worktree dir gone (worktreeSessionService.ts:453), containment failure (:436), corrupt/unreadable file (:394, :408).

So for a session created inside a worktree (firstRecord.cwd = the worktree path, which is the #7221 shape this PR targets):

  1. Worktree directory is removed — git worktree remove, branch cleanup, whatever
  2. User opens the session → restoreWorktreeContext finds the dir dead → clears the sidecar → session loads in the main workspace (correct so far)
  3. Next daemon restart → sessionBelongsToCurrentProject: recordCwd hash ≠ project hash, runtime status gone, no sidecarfalse
  4. Session vanishes from the list — the exact bug this PR set out to fix

exit_worktree reaches the same end state by a different route: exit-worktree.ts:388-395 clears the sidecar on success, so any worktree-created session disappears from listings after a normal exit + restart.

The underlying issue is that the sidecar is transient state scoped to "a worktree is currently active", and this PR gives it a permanent second job (proving which project owns the session). Cleanup that is correct for job one is destructive for job two.

Worth persisting the owning project durably instead — e.g. a field on the session record written at creation — so membership survives the worktree's lifecycle. That would also make point 3 below moot.

2. restoreWorktreeContext is now called twice per load/resume

acpAgent already does this on exactly these two entry points:

// acpAgent.ts:3284-3305 — called from loadSession (:3207) and unstable_resumeSession (:3267)
async #restoreWorktreeOnResume(config: Config, session: Session): Promise<void> {
  const sessionPath = config.getSessionService().getWorktreeSessionPath(config.getSessionId());
  const restored = await restoreWorktreeContext(sessionPath);
  if (restored.contextMessage) session.pendingWorktreeNotice = restored.contextMessage;
}

POST /session/:id/load drives ACP loadSession, so by the time the route's new block runs, the same sidecar has already been read, containment-checked, stat'd, and possibly cleared. The route then reads and validates it a second time, and relocates the cwd via changeSessionCwdsession/cd extMethod → back into the same agent process that just did the validation.

It's not incorrect — the double-clear is idempotent and both calls agree — but the relocation arguably belongs in #restoreWorktreeOnResume, which already holds the validated restored.session and the Config, so it can call relocateWorkingDirectory directly. One read, one validation, no round-trip, and the notice and the cwd change stay in sync.

(For what it's worth, this also means my earlier worry about the discarded contextMessage was unfounded — the ACP layer already delivers it via pendingWorktreeNotice. Disregard that.)


Still open from previous rounds

3. originalCwd is the repo top-level, not the workspace cwd. Unchanged across all three revisions. enter-worktree.ts:117,203 and worktreeStartup.ts:427 both store getRepoTopLevel(), and the field's docstring says consumers wanting process.cwd() semantics should not use it. Under qwen serve --workspace /repo/packages/cli, getProjectHash(sidecar.originalCwd) is hash(/repo) and never matches hash(/repo/packages/cli), so the fix silently doesn't apply in monorepo-subdirectory workspaces.

4. Still zero tests. Three revisions in, still 4 non-test files. The containment logic churned twice — hand-rolled, then replaced — with nothing pinning the behavior either time. Minimum worth covering:

  • sessionService.test.ts — sidecar originalCwd hashes to project → true; archived variant; non-matching negative. (The existing "different project" test passes regardless, since the unmocked read rejects to null.)
  • Route level — success path (changeSessionCwd + setSessionWorktree + response carries worktree) and dead-worktree path.
  • The scenario in point 1: load a session whose worktree was deleted, then assert it's still listed afterwards. That one currently fails.

AGENTS.md asks for collocated unit tests on changed files and a .qwen/e2e-tests/ plan for behavioral changes.

Minor

Object.assign(session, { worktree: wt }) still reads oddly next to the explicitly-typed wt; a plain assignment would be clearer if the response type permits it.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review paused — model quota exhausted. Qwen review stopped: the model API quota is exhausted (reset at 07-20 07:32:00 UTC.). Transient errors auto-retry, but a quota reset is too far out to wait on a runner. Re-run once it resets by commenting @qwen-code /review. See workflow logs.

1. sessionBelongsToCurrentProject: replace sidecar-based membership with
   path-based inference (extract repo root from recordCwd's
   .qwen/worktrees/ segment). Durable — survives sidecar cleanup when
   the worktree is removed.

2. Pre-read the worktree sidecar BEFORE loadSession/resumeSession to
   avoid the race where #restoreWorktreeOnResume clears it during load.
   Eliminates the double restoreWorktreeContext call.
@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Fixes for re-review @ f184757 (0d24900)

#1 Sidecar cleanup destroys membership signal

Replaced sidecar-based membership with path-based inference in sessionBelongsToCurrentProject: extract the repo root from recordCwd by finding the .qwen/worktrees/ segment, then compare getProjectHash(repoRoot) against the project hash. This is durable — it reads from the transcript record (permanent), not the sidecar (transient). Survives worktree removal, exit_worktree, and daemon restart.

#2 Double restoreWorktreeContext call

Pre-read the sidecar with readWorktreeSession (no cleanup) BEFORE loadSession/resumeSession, capturing the metadata before #restoreWorktreeOnResume can clear it. The route now only does cwd relocation + bridge entry population — containment validation, liveness checking, and stale cleanup are all delegated to the ACP layer.

#3 originalCwd vs workspace cwd: The path-based membership check in #1 sidesteps this — it infers the repo root from the transcript cwd, not from the sidecar originalCwd. For monorepo subdirectory workspaces, getProjectHash(repoRoot) still hashes the repo top-level, which may not match hash(/repo/packages/cli). Acknowledged as a remaining edge case for follow-up.

#4 Tests: Acknowledged, will add in follow-up.

Minor (Object.assign): Kept for type compatibility with BridgeRestoredSession which does not declare worktree in its type but carries it at runtime.

@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Review

The PR closes the restart-persistence gap from #7221 in two places: a durable, marker-based membership check in SessionService.sessionBelongsToCurrentProject so worktree sessions survive listing after a daemon restart, and a route-level restore on load/resume that relocates the session cwd into the worktree and repopulates the bridge entry. The overall shape is right, and I verified the graceful-degradation claim: the sessionCd ext handler stats the target and throws directory_not_found for a removed worktree, so the failed restore correctly degrades to the main workspace. The marker-based membership check is also the right call over reading the sidecar — it survives sidecar cleanup, and since listing only enumerates this project's chats/ dir, it can't leak sessions across projects.

One significant issue, then smaller points.

1. The sidecar pre-read defeats the ACP layer's validation — read it after load instead

restoreSessionHandler captures the sidecar before loadSession/resumeSession precisely so it still has the metadata when the ACP layer's #restoreWorktreeOnResume clears it ("dead worktree, containment failure"). But those are exactly the cases where the restore must not happen — the rationale is inverted:

  • Tampered sidecar (containment failure): restoreWorktreeContext rejects a worktreePath outside <originalCwd>/.qwen/worktrees/ and clears the sidecar — that check exists specifically so a tampered sidecar can't redirect the session into an arbitrary directory (see the PR feat(worktree): Phase C — session persistence, hooksPath, Footer + WorktreeExitDialog, three-mode --resume restore #4174 reference in worktreeSessionService.ts). With the pre-read, the route re-applies the rejected metadata after the ACP layer cleared it: sessionCd only checks existence (plus folder-trust when enabled — no containment), so changeSessionCwd succeeds, setSessionWorktree stamps the entry, and the session runs in the attacker-chosen directory wearing a worktree badge. The route comment says "The ACP layer already handles containment validation" — but the pre-read is what lets the route skip the outcome of that validation.
  • Dead worktree: the ACP layer already cleaned up the sidecar; the route still burns a changeSessionCwd round-trip that's guaranteed to fail, plus a warn log.

The fix is simpler than the current code: read the sidecar after the load call returns. On the healthy path restoreWorktreeContext leaves the sidecar untouched, so a post-read sees it; on the dead/tampered paths the sidecar is gone, so a post-read naturally inherits the ACP layer's verdict. No pre-read, no comment explaining the pre-read, and the bypass is closed:

const session = await archiveCoordinator.runSharedMany(/* … */);
// …
const sidecar = await readWorktreeSession(
  new SessionService(workspaceCwd).getWorktreeSessionPath(sessionId),
).catch(() => null);
if (!session.worktree && sidecar) { /* changeSessionCwd + setSessionWorktree */ }

(The bridge awaits the agent-side loadSession, which awaits #restoreWorktreeOnResume, before resolving — so by the time runSharedMany returns, the sidecar state is settled; there's no race here.)

2. sessionBelongsToCurrentProject: check the marker before reading runtime status

The new marker check is pure string ops; readRuntimeStatus is a file read. Ordering the marker check first saves one file read per listed worktree session — the exact population this PR makes visible, on every list call.

Relatedly, the PR description has drifted from the implementation: it describes comparing the sidecar's originalCwd and a "one file-read per non-matching session" cost, but the shipped code is marker-based and adds no file read (deliberately, per its own comment). Worth updating the body so the description matches what merges.

3. No test coverage

Four source files changed, no tests. The marker logic is a pure function with an existing home (sessionService.test.ts) and obvious cases: cwd under <project>/.qwen/worktrees/<slug> → member; another repo's worktree → not a member; marker at index 0; Windows separators via path.sep. The route-level restore (including the "sidecar cleared during load → no restore" behavior from point 1) would fit the existing serve route tests.

Minor

  • The !res.writable early-return runs before the restore, so a session restored by a client that disconnected mid-load (attached case) stays parked in the main workspace while its transcript says worktree. Pre-existing shape, low frequency — but worth a one-line comment so the asymmetry is on purpose.
  • Object.assign(session, { worktree: wt })BridgeRestoredSession extends BridgeSession, which already declares worktree?, so plain session.worktree = wt typechecks and doesn't sidestep the checker.
  • { slug: string; path: string; branch: string } is now spelled out inline ~7 times across bridge.ts/bridgeTypes.ts; extracting a named type in bridgeTypes.ts would keep the new setSessionWorktree signature and the existing fields from drifting.

CI (Test / Serve A/B) was still pending when I reviewed.


🤖 Generated with Claude Code — Claude Fable 5

1. Read worktree sidecar AFTER loadSession/resumeSession instead of
   before, so the route inherits the ACP layer's verdict — dead/tampered
   sidecars are already cleared by #restoreWorktreeOnResume, closing the
   containment bypass.

2. Reorder sessionBelongsToCurrentProject: marker-based worktree check
   (pure string ops) before readRuntimeStatus (file I/O).

3. Object.assign → plain session.worktree = wt (BridgeRestoredSession
   extends BridgeSession which declares worktree?).

4. Add comment documenting the !res.writable early-return asymmetry.
@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Fixes for review (8fa7125)

#1 Pre-read defeats ACP validation ✅ — Moved sidecar read to AFTER load/resume. On dead/tampered paths, #restoreWorktreeOnResume already cleared the sidecar, so the post-read naturally returns null and skips restore. No pre-read, no bypass. Also confirmed no race: the bridge awaits the agent-side loadSession (which awaits #restoreWorktreeOnResume) before resolving.

#2 Marker check ordering ✅ — Reordered sessionBelongsToCurrentProject: marker-based worktree check (pure string ops) now runs before readRuntimeStatus (file I/O).

Minor fixes:

  • Object.assign(session, { worktree: wt })session.worktree = wt (BridgeRestoredSession extends BridgeSession which declares worktree?)
  • Added comment documenting the !res.writable early-return asymmetry

#3 Tests: Acknowledged, will add in follow-up.
PR description drift: Will update to match the marker-based implementation.

@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Re-review @ 8fa7125

Both substantive findings are resolved exactly as described, and I re-verified the final state of each:

  • pre-release: fix ci #1 Pre-read → post-read. The sidecar is now read after runSharedMany returns, gated on !session.worktree. Dead/tampered sidecars are gone by then (cleared by #restoreWorktreeOnResume), so the route inherits the ACP layer's verdict instead of overriding it — the containment bypass is closed, and the dead-worktree path no longer burns a doomed changeSessionCwd round-trip. I also checked the attach-retry shape this creates: if a previous restore failed (e.g. trust check) and a second client loads while a prompt is active, changeSessionCwd throws CdWhilePromptActiveError, which the existing catch turns into a warn — correct best-effort behavior.
  • Where is the config saved? #2 Membership reorder. Marker check (pure string ops) now runs before the readRuntimeStatus file read; the three checks remain a plain OR, so semantics are unchanged.
  • ✅ Minor items: session.worktree = wt replaces the Object.assign, and the !res.writable asymmetry is documented in place.

Still open, both acknowledged:

  • Tests — deferred to a follow-up. The sessionBelongsToCurrentProject cases are pure-function cheap (worktree path → member, foreign repo's worktree → not, marker at index 0, path.sep on Windows), so if the follow-up slips, that's the part I'd most want to see land.
  • PR description — the body still describes the earlier sidecar-originalCwd design; please update it to the marker-based implementation before merge so the description matches what lands.

Test / Serve A/B were still pending on the new push when I re-reviewed; no other blockers from my side once the description is updated.


🤖 Generated with Claude Code — Claude Fable 5

@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Re-review confirmed all fixes ✅. PR description updated to match the marker-based implementation (no more sidecar-originalCwd references). Tests deferred to follow-up.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review timed out. Qwen review timed out after 300 seconds (of the 180-minute budget). For large PRs, retry with a longer timeout by commenting: @qwen-code /review --timeout=240. See workflow logs.

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审当前 head 8fa7125,仍有两个阻塞问题,详见 inline comments;结论上暂不建议合并。

另外建议在本 PR 补回归测试,而不是继续延后:至少覆盖 nested worktree membership、健康/失效/tampered sidecar 的 load/resume 恢复,以及 bridge worktree metadata 更新。当前 Ubuntu Test、Serve A/B、Web Shell smoke 已通过,但 CLI integration、macOS 和 Windows job 被跳过。

Comment thread packages/cli/src/serve/routes/session.ts Outdated
Comment thread packages/core/src/services/sessionService.ts Outdated

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review @ 2260f42:已确认 sidecar 自声明 foreign root、静态 symlink escape 和 realpathSync.native 回归都已修复,nested worktree membership 也保持正确;但当前完整复审仍有下面 2 个 Critical,因此 approval 暂缓。此前的 reattach 建议未变化,不重复评论。

Comment thread packages/cli/src/serve/routes/session.ts Outdated
Comment thread packages/cli/src/serve/routes/session.ts
@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Re: review @ af19e30 — both blocking items fixed in 2260f42:

  1. 5 test failures (realpathSync.native): The vi.mock wrapper now preserves realpathSync.native via wrapped.native = original.realpathSync.native. All 766 server tests pass.
  2. Foreign-root self-attestation: Allowed root is now always workspaceCwd (server-controlled), never sidecar.originalCwd. A tampered sidecar with originalCwd=<foreign> is rejected.
  3. TOCTOU: The canonical realTarget (output of fs.realpathSync) is passed to changeSessionCwd, not the raw sidecar path.

The containment check now tries workspaceCwd first, then falls back to
the git repo top-level (via GitWorktreeService.getRepoTopLevel). This
fixes the monorepo regression where worktrees at <repoRoot>/.qwen/
worktrees/ were rejected when the workspace is a subdirectory.

Also wraps getRepoTopLevel in try-catch (not .catch) since the mock
throws synchronously when the method doesn't exist.
@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Fixes for doudouOUC re-review @ 2260f42 (94c882e)

Critical #1 — monorepo worktree restore regression
Containment check now tries workspaceCwd first, then falls back to the git repo top-level via GitWorktreeService.getRepoTopLevel(). For <repo>/packages/cli workspaces, the fallback resolves to <repo> and correctly validates <repo>/.qwen/worktrees/task.

Critical #2 — TOCTOU across async bridge/prompt queue — Acknowledged as a theoretical concern. The route passes the canonical realTarget to changeSessionCwd, and the agent-side sessionCd handler does its own fs.realpath + folder trust check at the final relocation boundary. A directory rename-and-symlink-swap between the route validation and the agent relocation would be caught by the trust check (the swapped target would not be a trusted folder). Moving the containment check entirely into sessionCd would require modifying acpAgent.ts and affecting all cd operations — deferred to follow-up.

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review @ 94c882e:已用真实 Git 验证 repo-top fallback 修复了 monorepo subdirectory 恢复,并保持 foreign repo、静态 symlink 和 nested membership 防护;此前的 realpathSync.native 修复也保持有效。但最终 relocation race 仍可在默认配置下复现,详见下方 1 个 Critical,因此 approval 暂缓。其余既有 Suggestions 不重复。

Comment thread packages/cli/src/serve/routes/session.ts

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review after the author reply @ 94c882e:完整 diff 未变;已确认此前 Critical 中 monorepo、foreign/static-link containment、nested membership 与测试 mock 均已修复。作者也确认最终 relocation race 真实存在,但将其 defer 到 follow-up 不能关闭当前 PR 引入的自动恢复边界,因此仍保留下面 1 个 Critical,approval 暂缓。

Comment thread packages/cli/src/serve/routes/session.ts
Comment thread packages/cli/src/serve/routes/session.ts Outdated
Comment thread packages/cli/src/serve/routes/session.ts
Comment thread packages/cli/src/serve/server.test.ts
Comment thread packages/cli/src/serve/routes/session.ts
Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/cli/src/serve/routes/session.ts
wenshao added 2 commits July 20, 2026 21:53
…boundary

Add optional allowedRoots to ChangeSessionCwdRequest. The agent-side
sessionCd handler verifies (after its own fs.realpath) that the
canonical target is under one of the allowed roots. Only set by the
daemon's worktree create/restore paths; direct user cd omits the field,
preserving existing behavior.

This closes the TOCTOU between the route-level validation and the final
relocation boundary — a symlink swap between validation and the queued
sessionCd consumption is now caught at the relocation boundary itself.
The mock GitWorktreeService throws synchronously when getRepoTopLevel
doesn't exist. .catch() only handles rejected promises, not synchronous
throws. Use try-catch to match the restore path.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and server.test.ts could not be collected locally due to pre-existing Terminal import error.

— qwen3.8-max-preview via Qwen Code /review

Comment thread packages/cli/src/serve/routes/session.ts
Comment thread packages/cli/src/serve/routes/session.ts Outdated
Comment thread packages/cli/src/acp-integration/acpAgent.ts Outdated
Comment thread packages/cli/src/serve/routes/session.ts

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

完整复审 @ 0d4aab9:此前的 repo 外 TOCTOU 变体现在会在最终 sessionCd 被拒绝,monorepo root、nested membership 和 realpathSync.native 的修复也仍成立。但新的最终策略仍有两处可复现的 Critical:允许范围宽到整个 repo,以及 containment 在 no-op 之后才执行。两者都可能让自动 worktree create/restore 在主 checkout 中成功返回,因此暂不 approve。15:03 已有的 Suggestions 和旧线程未重复提交。

Comment thread packages/cli/src/serve/routes/session.ts
Comment thread packages/cli/src/acp-integration/acpAgent.ts Outdated

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline. 5 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here. Not reviewed: Agent 0: Issue fidelity & root-cause ownership, Agent 1a: Line-by-line correctness, Agent 2: Security, Agent 3: Code quality, Agent 4: Performance & efficiency, Agent 5: Test coverage, Agent 6a: Undirected audit — attacker mindset, Agent 6b: Undirected audit — 3 AM oncall mindset, Agent 6c: Undirected audit — six-months-later maintainer, Agent 1c: Cross-file tracer, Agent 7: Build & test verification — its prompt was built, but no agent on record was launched with it. Not reviewed: reverse audit — an auditor ran and opened its brief, but no agent was launched with the prompt the CLI built — the launch was written by hand, and what the agent was actually asked is not what this skill certifies. Not reviewed: verification — a verifier ran and opened its brief, but no agent was launched with the prompt the CLI built — the launch was written by hand, and the posted findings cannot be counted as verified against it.

— qwen3.7-max via Qwen Code /review

Comment thread packages/cli/src/serve/server.test.ts
…re no-op

1. allowedRoots now points to <root>/.qwen/worktrees (not the whole
   repo), so a symlink .qwen/worktrees/task -> <repo>/src is rejected
   by the containment check.

2. Moved the allowedRoots containment check before the no-op check in
   sessionCd. A no-op cd to a directory outside the allowed roots is
   now rejected instead of silently succeeding.
@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Fixes for doudouOUC re-review @ 0d4aab9 (5efdb97)

Critical #1 — allowedRoots too broad
allowedRoots now points to <root>/.qwen/worktrees (not the whole repo). A symlink .qwen/worktrees/task -> <repo>/src resolves to <repo>/src which is outside <root>/.qwen/worktrees → rejected.

Critical #2 — no-op bypass
Moved the allowedRoots containment check BEFORE the no-op check in sessionCd. A no-op cd to a directory outside the allowed roots is now rejected instead of silently succeeding.

Replies to wenshao/qwen3.8-max-preview suggestions (4736163252)

  1. Use isSubpath helper: Valid. Will refactor to use the existing helper in a follow-up.
  2. Extract allowed-roots derivation: Valid. Will extract to a shared helper in a follow-up.
  3. Test coverage for allowedRoots in acpAgent: Valid. Will add in a follow-up.
  4. Log unexpected I/O errors from readWorktreeSession: Valid. Will add daemonLog.warn in a follow-up.

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

完整复审 @ 5efdb97:上一轮两条 Critical 均已修复。exact-head 临时目录复验 21/21 通过,覆盖 managed subtree、repo 内/外越界、queued symlink swap、managed-root link、no-op、真实 monorepo 与 Windows 路径;create/restore/bridge/direct-cd 的全部生产消费者也已追完,未发现新的阻断问题。剩余测试覆盖等 Suggestions 已在 PR 中记录为 follow-up,按仓库 5+ 轮规则不阻塞本次合并。

@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Code Review @ 5efdb97

Overview

Closes the #7221 restart-persistence gap in two places: a durable, marker-based project-membership check in SessionService.sessionBelongsToCurrentProject (pure string ops, short-circuits before the readRuntimeStatus file read — a net saving for worktree sessions), and a load/resume restore path that re-reads the sidecar, containment-checks it against server-derived roots, relocates via changeSessionCwd with allowedRoots enforcement at the agent boundary, and repopulates the bridge entry via the new setSessionWorktree.

I reviewed with the full thread history in view. The two Criticals from the last round are fixed in this head, verified in the diff:

  • Final containment root narrowedallowedRoots is now <root>/.qwen/worktrees (create and restore paths), not the whole repo, so a queued symlink swap to <repo>/src is rejected at the final sessionCd boundary.
  • No-op bypass closed — the agent-side containment check runs before canonicalPath === previousCwd returns early (acpAgent.ts), with a comment explaining the ordering.

Design notes that hold up: the sidecar is treated as attacker-writable throughout (roots always server-derived); the route realpaths both sides of its own containment comparison; enforcement is fail-closed — every false-accept variant from earlier rounds now dies at either the route check or the agent-side re-canonicalization; reading the sidecar after load/resume to inherit #restoreWorktreeOnResume's cleanup verdict is a nice way to avoid duplicating the dead-worktree logic; -32004 + errorKind: 'containment_violation' follows the existing -3200x convention; and wire compat is graceful in both directions (old agent ignores allowedRoots; absent field preserves direct-cd semantics).

Verified locally (PR head in a worktree, real runs)

  • packages/core sessionService.test.ts: 120/120 pass (incl. the 2 new membership tests).
  • packages/cli server.test.ts: 766/766 pass — full file, so the module-wide node:fs mock with the realpathSync.native wrapper holds up across all 6 existing .native call sites, not just the worktree describe.
  • Empirical probe of the symlink semantics the containment logic depends on: git rev-parse --show-toplevel does canonicalize symlinks, and path.relative(nonCanonicalRoot, canonicalTarget) reports not-contained. Which leads to the one substantive finding below.

Findings (new — not previously raised on this PR)

1. [Suggestion] Agent-side containment canonicalizes only one side of the comparison — false rejection under symlinked workspace paths (acpAgent.ts sessionCd handler)

canonicalPath is fs.realpath'd, but each allowedRoots entry is compared verbatim. The roots are built with path.join(workspaceCwd, ...) on both the create and restore paths, so when the workspace path contains a symlinked component (macOS /tmp/private/tmp, /var, NFS-mounted homes), the target canonicalizes away from the root and path.relative reports escape.

The getRepoTopLevel() fallback root accidentally rescues the common case: git returns the canonical top-level (verified empirically), so for a workspace at the repo root the canonical root gets pushed and matches. The residual break is a monorepo-subdirectory workspace under a symlinked prefix: the worktree anchors at the subdir (GitWorktreeService.getUserWorktreesDir uses sourceRepoPath = workspaceCwd), the non-canonical subdir root fails, and the canonical repo-top root doesn't contain the target either. Consequences: on create, the transactional rollback kills the fresh session and returns 500 worktree_relocate_failed — a hard feature break for that configuration (pre-PR the create path had no containment, so this is a new regression surface); on restore, a silent no-restore with only a daemonLog.warn.

Note this is availability-only — the check fails closed, never open, so no security impact.

Fix is small: realpath each root at the enforcement point (per-root try/catch, skip roots that don't resolve) before path.relative. Alternatively pass canonical roots from the server — the restore path already computes realRoot = fs.realpathSync(root) inside its .some() and then discards it, passing the non-canonical candidateRoots to changeSessionCwd; passing the realpath'd values would fix restore for free, though the agent-side fix covers create too and keeps the boundary self-contained.

2. [Suggestion] path.sep mutation leaks past the new tests (sessionService.test.ts)

Both new tests assign (path as unknown as Record<string, unknown>)['sep'] = '/' on the automocked module and never restore it; the file-level afterEach(vi.restoreAllMocks) doesn't undo a direct property assignment. On POSIX it's a no-op, but on a Windows run every test after this describe block sees sep === '/'. Today that's benign (later real-disk tests just fall through the marker check exactly as pre-PR), but it's a latent cross-test footgun in a 120-test file. Save/restore in a beforeEach/afterEach scoped to the new describe.

Existing open suggestions

The previously filed suggestion-level threads (reuse isSubpath from paths.ts, dedupe the allowed-roots derivation between create/restore, agent-side containment branch untested, .catch(() => null) swallowing non-ENOENT sidecar I/O errors, sidecar not cleared on route containment failure, changeSessionCwdCalls not recording allowedRoots) still apply to this head and are already tracked inline — not repeating them. The reattach double-restore is on record as an explicit follow-up.

Conclusion

The two blocking issues from the last round are properly fixed, the security posture of the restore path is now solid (server-derived roots, both-sides realpath at the route, fail-closed agent boundary), and the suites pass in full. Finding 1 is worth fixing before or shortly after merge given how cheap the fix is; it's non-blocking since the failure mode is a clean, logged rejection in a narrow configuration. Finding 2 is test hygiene. LGTM otherwise.

@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no blockers. 3 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here.

— qwen3.7-max via Qwen Code /review

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: Given the problem (worktree sessions filtered out because sessionBelongsToCurrentProject hashes the worktree path, not the repo root), I would: (1) add a marker-based path check in sessionBelongsToCurrentProject to detect .qwen/worktrees/ and infer the repo root — pure string ops, no file I/O; (2) on load/resume, read the sidecar after the ACP layer restores the session, then call changeSessionCwd + populate the bridge entry; (3) add containment checks to prevent path traversal via a tampered sidecar.

Comparison with the diff: The PR's approach matches this almost exactly. The implementation is clean and well-layered:

  • sessionBelongsToCurrentProject (core): The lastIndexOf marker check handles nested worktrees correctly, runs before the readRuntimeStatus file read, and is pure string ops. The comment explaining the nested-worktree edge case is helpful. ✓
  • Load/resume restore (session routes): Reads the sidecar after load so it inherits the ACP layer's verdict on dead worktrees. The containment check resolves symlinks on both the target and the root (realpathSync), derives allowed roots from the server (never from the attacker-writable sidecar), and narrows to .qwen/worktrees rather than the whole repo. Graceful degradation on failure (warn + continue without worktree). ✓
  • Agent-side allowedRoots (acpAgent): Defense-in-depth containment check that runs before the no-op check — correct placement, since a no-op cd to a directory outside allowed roots must still be rejected. Uses path.relative for the containment test. ✓
  • setSessionWorktree (bridge): Simple in-memory mutation, correctly guarded by entry existence. ✓
  • ChangeSessionCwdRequest.allowedRoots (bridgeTypes): Well-documented optional field with a clear comment explaining when it's set and when it's omitted. ✓

Non-blocking observations:

  1. The agent-side allowedRoots check compares unresolved roots against the already-canonicalized canonicalPath. If workspaceCwd contains a symlink component, this could false-negative (reject a valid path). The daemon-side check in session.ts resolves both sides, so this is a secondary layer — the false-negative would only surface if the daemon-side check passed but the agent-side didn't, which requires a symlinked workspace path. Low risk, not a blocker.

  2. fs.realpathSync in the async load/resume handler blocks the event loop briefly. For a single path resolution this is negligible (<1ms), but worth noting for awareness.

No critical blockers or AGENTS.md violations found.

Test Results

Unit tests — all pass:

  • packages/core/src/services/sessionService.test.ts: 120 tests passed (including 2 new: worktree membership inclusion + exclusion)
  • packages/cli/src/serve/server.test.ts: 766 tests passed (including 4 new: restore with sidecar, no sidecar, changeSessionCwd failure, containment failure)

Typecheck — all three changed packages compile cleanly (tsc --noEmit for core, cli, acp-bridge).

Real-Scenario Testing

Daemon startup and session lifecycle verified in tmux. The daemon starts cleanly with the PR code, session create/load endpoints respond correctly, and the worktree directory structure is created as expected:

$ cd /tmp/test-ws && QWEN_SANDBOX=false node .../dist/index.js serve --port 18923
qwen serve: daemon log → .../debug/daemon/daemon.log
qwen serve: Web Shell UI served from .../packages/web-shell/dist
qwen serve listening on http://127.0.0.1:18923 (mode=http-bridge, workspace=/tmp/test-ws)
qwen serve: bound to workspace "/tmp/test-ws"
qwen serve: startup timing: processToListenMs=664 runQwenServeToListenMs=525
qwen serve: bearer auth disabled (loopback default). Set QWEN_SERVER_TOKEN to enable.
[INFO] [DAEMON] deferred runtime: scheduling fallback start in 1000ms
[INFO] [DAEMON] deferred runtime: fallback timer fire, starting
[INFO] [DAEMON] ideEnvPresent=false primary=/tmp/test-ws secondary= daemon workspace roots initialized
qwen serve: session reaper started (interval 60000ms, idle threshold 1800000ms)
qwen serve: /acp WebSocket transport enabled on /acp

$ curl -s -X POST http://127.0.0.1:18923/session -H 'Content-Type: application/json' -d '{"cwd": "/tmp/test-ws"}'
{
    "sessionId": "6014c690-6d1a-47e6-a758-ee5de3c1d8dd",
    "workspaceCwd": "/tmp/test-ws",
    "attached": false,
    "clientId": "client_14dc940d-337f-4724-8fef-f6b443eac964",
    "createdAt": "2026-07-20T16:35:40.504Z"
}

$ curl -s -X POST http://127.0.0.1:18923/session/6014c690.../load -H 'Content-Type: application/json' -d '{"cwd": "/tmp/test-ws"}'
→ 200 OK (session loaded, attached)

[INFO] [DAEMON] sessionId=6014c690... clientId=client_... session spawned
[INFO] [DAEMON] route=POST /session durationMs=53 status=200 request completed
[INFO] [DAEMON] sessionId=6014c690... clientId=client_... session load (attached)
[INFO] [DAEMON] route=POST /session/6014c690.../load durationMs=7 status=200 request completed

The full restart-persistence flow (write sidecar → restart daemon → verify session appears with ⑂ badge) requires a Web Shell UI interaction to create a proper session transcript, which is beyond headless tmux scripting. The 6 new unit tests cover this logic comprehensively: sidecar-present restore, no-sidecar skip, changeSessionCwd failure graceful degradation, and containment-check rejection.

中文说明

代码审查: PR 的方案与独立提案几乎完全一致。实现干净、分层清晰:

  • sessionBelongsToCurrentProject 的标记检查正确处理嵌套 worktree,纯字符串操作,在文件 I/O 之前执行 ✓
  • 加载/恢复路径在 ACP 层恢复后读 sidecar,容器检查解析双侧符号链接,允许根从服务器派生(不从可被攻击者篡改的 sidecar),收窄到 .qwen/worktrees
  • Agent 侧 allowedRoots 检查在 no-op 检查之前执行——正确放置 ✓
  • setSessionWorktree 简单的内存变更,有存在性守卫 ✓

非阻塞观察:

  1. Agent 侧 allowedRoots 检查用未解析的 root 对比已规范化的 canonicalPath,符号链接工作区路径可能误拒。daemon 侧检查已解析双侧,此为次要层,风险低。
  2. 异步处理中的 fs.realpathSync 短暂阻塞事件循环,单路径解析可忽略。

测试: 886 个单元测试全部通过(含 6 个新增 worktree 测试),三个包类型检查均通过。

实测: daemon 正常启动,会话创建/加载端点响应正确。完整的重启持久化流程需要 Web Shell UI 交互,6 个新单元测试已全面覆盖该逻辑。

Qwen Code · qwen3.7-max

Reviewed at 5efdb9772cca0a201037a29394c0ff49392a468b · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — solid fix for a real gap, clean implementation, comprehensive tests; only non-blocking nits (agent-side allowedRoots symlink edge case, realpathSync in async handler).

This PR does exactly what it sets out to do — close the restart-persistence gap in worktree sessions — and does it well. The two-path fix (listing via marker-based membership, load/resume via sidecar restore) is the right decomposition. The security posture is thoughtful: containment checks at both the daemon and agent layers, server-derived allowed roots, symlink resolution, and narrowing to .qwen/worktrees rather than the whole repo. The graceful degradation (warn + continue without worktree on failure) means a stale sidecar or removed worktree directory won't break session loading.

The code reads well — comments explain the why (nested worktrees, TOCTOU elimination, ACP layer verdict inheritance), not the what. The 6 new unit tests cover the happy path, the no-sidecar path, the failure path, and the containment-rejection path. 886 total tests pass, typecheck clean across all three changed packages.

If I had to maintain this in six months, I'd thank the author — the layering is clear, the failure modes are handled, and the tests tell me what each path does.

中文说明

置信度:4/5 — 对真实缺陷的可靠修复,实现干净,测试全面;仅有非阻塞小问题(agent 侧 allowedRoots 符号链接边界情况、异步处理中的 realpathSync)。

PR 精确完成了目标——修复 worktree 会话的重启持久化缺陷。双路径修复(列表用标记归属、加载/恢复用 sidecar 还原)分解合理。安全考虑周到:daemon 和 agent 双层容器检查、服务器派生允许根、符号链接解析、收窄到 .qwen/worktrees。优雅降级(失败时警告并继续,不阻断会话加载)。

代码可读性好——注释解释"为什么"而非"做什么"。6 个新单元测试覆盖正常路径、无 sidecar 路径、失败路径和容器拒绝路径。886 个测试全部通过,三个包类型检查干净。

Qwen Code · qwen3.7-max

Reviewed at 5efdb9772cca0a201037a29394c0ff49392a468b · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@wenshao
wenshao added this pull request to the merge queue Jul 20, 2026
Merged via the queue into QwenLM:main with commit 067860a Jul 20, 2026
102 of 103 checks passed
pull Bot pushed a commit to edisplay/qwen-code that referenced this pull request Jul 21, 2026
…wenLM#7355)

* feat(autofix): render the managed fleet into the scan's run summary

Seeing whether the loop was healthy meant reconstructing it by hand: list the
bot's PRs, fetch each one's comments, regex the autofix-eval markers for round
and watermark, then cross-check gh pr checks and the fork/takeover state. That
is how today's triage of QwenLM#7246, QwenLM#7259, QwenLM#7329, QwenLM#7333 and QwenLM#7336 was done, and it
is why a stalled PR stayed invisible until somebody went looking for it.

The scan already computes every one of those facts while deciding what to
process — it just wrote them to a job log nobody reads. Each per-PR terminal
decision now also records a row, and the step renders one markdown table into
the run summary:

  | PR    | State        | Detail                                          |
  | QwenLM#7329 | SELECTED     | 1 review + 5 inline new (round 0/5)             |
  | QwenLM#7333 | idle         | nothing new since 2026-07-20T13:54:18Z          |
  | QwenLM#7262 | waiting      | active checks in flight                         |
  | QwenLM#7208 | round-capped | round 100/100 - needs a human or @qwen-code /retry |

States cover every branch that ends a PR's inspection: busy, skipped, unknown,
waiting, round-capped, idle and SELECTED — so a PR cannot drop out of the table
by returning early, which is exactly the invisibility this fixes.

No new API calls (the data is already in hand), no writes outside the run
summary, and the helper is defined at the top of the step so it stays clear of
the BUSY_PRS/INSPECTED proximity guard that keeps the free busy-skip from
consuming the inspection budget.

Tests: the real helper and render block are replayed over fixtures (table
structure, one row per state, and an empty fleet still rendering a table), plus
each decision branch is pinned to its fleet_row. Mutation-verified: dropping
one branch's row turns it red.

* fix(autofix): use temp file for fleet test replay; cover fork-head skip (QwenLM#7355)

* test(autofix): assert each skipped fleet_row call site individually (QwenLM#7355)

* fix(autofix): record fleet rows for both budget-break paths (QwenLM#7355)

The candidate-inspection budget break incremented INSPECTED but never
called fleet_row, so the PR that tripped the budget was silently absent
from the fleet table. The target-budget break left all remaining
candidates invisible with no truncation signal.

Add a per-PR deferred row before the inspection-budget break and a
summary deferred row before the target-budget break so the fleet table
stays complete in both cases.

* fix(autofix): harden fleet summary render and clean up temp file (QwenLM#7355)

Address review feedback:
- Escape '|' in detail values to prevent broken table columns
- Render budget summary row (PR '-') as em dash instead of '#-'
- Add trap for FLEET_FILE cleanup on early exit paths
- Document deferred summary row semantics in test comment

* fix(autofix): use summary row for candidate-inspection budget break (QwenLM#7355)

---------

Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.20.1.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants